przn 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a02ff63ef48dadc49d46767f2f60e32457cd9562dffa726e6a6b3a811c3cf68d
4
- data.tar.gz: 0a4c431ac13a990c06397626749de0b495e47efa3af5c3233800868b7fb56b41
3
+ metadata.gz: 926f4301117fde5642b907fbf96340d1e547af78086eb1fd47ed45fa16472e5e
4
+ data.tar.gz: 8cbbf68f1d784b6c6ed2ce7ba600c0a214dc8a688a785b5044701b9e8c587b39
5
5
  SHA512:
6
- metadata.gz: 98e45b37b5125ece026f9140dbd68225beddde0eb1eac76ad0e614b28610898580acc5a9e03a3cd36313740165216cec646e1224f80f2e951a4c82d3dde89d9d
7
- data.tar.gz: eef32162b80aedbef88fafbc3834e85e12eda8db63918a762183f341f45ee694397aa39407c5887f7fb004ccbd0276cf59beded6a695a8441bcefceb3f8416ff
6
+ metadata.gz: 6f6a58582c7a21ccfab9750ee0ebc39525626af2233d503497e933a1d5b108bcf08b34c156115730c145e3c4dd7473b8d48633597d68b487bcc5702e29132183
7
+ data.tar.gz: 0a3bf226c4c4e6300d515cf50274c1ad266556c9a984df6090ed9dec1dbcc7cc3e878675131e2d26f0e71b1167046f1d5ea3e7f1b0f965101f942e5a728d9967
data/README.md CHANGED
@@ -75,6 +75,8 @@ przn --export prawn -o output.pdf your_slides.md
75
75
 
76
76
  przn's Markdown format is compatible with [Rabbit](https://rabbit-shocker.org/)'s Markdown mode.
77
77
 
78
+ > **HTML-ish tag attributes** — every `<tag attr=value>` block below (`<bg>`, `<at>`, `<img>`, `<font>`) accepts three value forms: double-quoted `attr="value"`, single-quoted `attr='value'`, and unquoted `attr=value` (HTML5-ish — anything that isn't whitespace, `=`, `<`, `>`, a quote, or backtick). Self-closing tags need a space before `/>` when the last attribute is unquoted (`<img src=foo.png />`).
79
+
78
80
  ### Slide splitting
79
81
 
80
82
  Slides are separated by `#` (h1) headings.
@@ -235,6 +237,49 @@ content...
235
237
 
236
238
  The previous slide's background is cleared on every navigation, and on `przn` exit, so your shell isn't left tinted.
237
239
 
240
+ ### Absolute-position text
241
+
242
+ Place text at an arbitrary `(column, row)` on the slide, escaping the normal top-down paragraph flow:
243
+
244
+ ```markdown
245
+ # Layout test
246
+
247
+ <at x="10" y="5">top-left ish</at>
248
+ <at x="40" y="15"><size=3>BIG</size></at>
249
+ <at x="80" y="25"><color=red>warn</color></at>
250
+ <at x="50%" y="50%">dead center</at>
251
+
252
+ {::at x="10" y="20"}same thing, kramdown form{:/at}
253
+ ```
254
+
255
+ - `x` / `y` accept two forms:
256
+ - **Plain integer** — 1-based terminal cells, matching the cursor-position escape (`\e[y;xH`). `x="1" y="1"` is the very top-left of the slide pane.
257
+ - **Percent** (`x="50%"`, `y="100%"`) — resolves against the terminal's current width / height. Auto-adjusts when the pane is resized.
258
+ - Content is parsed inline, so all the usual styling works inside an `<at>` — `<size>`, `<color>`, `<font>`, `**bold**`, `*italic*`, etc.
259
+ - The block doesn't take up vertical space in the slide's layout — paragraphs around it render in their normal positions and the absolute placement layers on top. Useful for overlaying labels on a `<bg .../>` gradient or pinning annotations to specific cells.
260
+ - Out-of-range coordinates clamp into the visible area; missing / unparseable coordinates skip silently.
261
+
262
+ ### Image
263
+
264
+ Embed an image with the standard markdown form, or the `<img>` XML form when you want to absolute-position it. Both produce identical output — `<img>` just opens the door to extra attributes like `x` / `y`.
265
+
266
+ ```markdown
267
+ ![](doge.png){:relative_height="70"}
268
+ <img src="doge.png" relative_height="70"/>
269
+
270
+ <img src="doge.png" x="5" y="3" relative_height="40"/>
271
+ <img src="doge.png" x="50%" y="50%" relative_height="40"/>
272
+ ```
273
+
274
+ - `src` is required; `alt` and `title` are accepted and ignored at render time (kept for accessibility / future use).
275
+ - `relative_height="N"` caps the image at N % of the terminal height (default 70). Aspect ratio is preserved. `relative_width="N"` is the same for the horizontal dimension.
276
+ - `height="N%"` / `width="N%"` are short-form aliases for `relative_height` / `relative_width` (both forms — `<img>` and `![]{:...}` — accept the alias). An explicit `relative_*` on the same block wins; a non-`%` value (`height="40"`) is left alone since pixel units aren't supported.
277
+ - `x` / `y` (optional) anchor the image's top-left at an absolute cell. Same two forms as [`<at>`](#absolute-position-text):
278
+ - **Plain integer** — 1-based terminal cells.
279
+ - **Percent** — resolves against the terminal's current width / height.
280
+ - With `x` and `y` set, the image layers on top of the slide and contributes 0 to the layout flow — paragraphs around it render in their normal positions, exactly like `<at>`. Without `x` / `y`, the image stays horizontally centered and takes up its natural height in the flow.
281
+ - Rendering backend: Kitty Graphics Protocol on terminals that support it (PNG uploaded once and reused; JPG goes through `kitten icat`), Sixel as a fallback. Other terminals show nothing in place of the image.
282
+
238
283
  ### Comments
239
284
 
240
285
  ```markdown
@@ -44,6 +44,7 @@ module Przn
44
44
  @audience_link.close
45
45
  end
46
46
  @terminal.write "\e]7772;bg-clear\a"
47
+ @terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
47
48
  @terminal.show_cursor
48
49
  @terminal.leave_alt_screen
49
50
  end
@@ -91,6 +91,16 @@ module Przn
91
91
  "\e_Ga=p,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
92
92
  end
93
93
 
94
+ # Kitty Graphics Protocol: delete every placement and free the
95
+ # stored image data. Used on quit so previously-rendered images
96
+ # don't leak through onto the user's restored shell screen
97
+ # (placements aren't tied to the alt-screen buffer in most
98
+ # kitty-protocol implementations, so leaving the alt screen
99
+ # alone isn't enough to hide them). `q=2` suppresses the OK reply.
100
+ def kitty_clear_all
101
+ "\e_Ga=d,d=A,q=2\e\\"
102
+ end
103
+
94
104
  # Sixel via img2sixel
95
105
  def sixel_available?
96
106
  @sixel_available = system('command -v img2sixel > /dev/null 2>&1') if @sixel_available.nil?
data/lib/przn/parser.rb CHANGED
@@ -25,6 +25,15 @@ module Przn
25
25
  'bright_white' => 97
26
26
  }.freeze
27
27
 
28
+ # HTML-ish attribute, three accepted value forms:
29
+ # key="value" key='value' key=bareword
30
+ # The unquoted token excludes whitespace, `=`, `<`, `>`, `"`, `'` and
31
+ # backtick, matching the spirit of HTML5's unquoted-attribute grammar.
32
+ # `/` is intentionally NOT excluded so paths like `src=path/to/file`
33
+ # work — which means self-closing tags need a space before `/>` when
34
+ # the last attribute is unquoted (`<img src=foo.png />`).
35
+ ATTR_RE_SRC = '\w+=(?:"[^"]*"|\'[^\']*\'|[^\s=<>"\'`]+)'
36
+
28
37
  module_function
29
38
 
30
39
  def parse(markdown)
@@ -79,9 +88,20 @@ module Przn
79
88
  # Slide background (Echoes OSC 7772):
80
89
  # <bg color="#..."/> — solid (bg-color)
81
90
  # <bg from="#..." to="#..." angle="N"/> — linear gradient (bg-gradient)
82
- when /\A\s*<bg((?:\s+\w+="[^"]+")*)\s*\/>\s*\z/
91
+ # Attribute values may be double-quoted, single-quoted, or
92
+ # unquoted (HTML5-ish — see ATTR_RE_SRC).
93
+ when %r{\A\s*<bg((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
83
94
  blocks << {type: :bg, attrs: parse_xml_attrs(Regexp.last_match(1))}
84
95
 
96
+ # Absolute-position text:
97
+ # <at x="N" y="N">content</at>
98
+ # {::at x="N" y="N"}content{:/at}
99
+ # Content can include inline markup (size, color, font, bold, …).
100
+ when %r{\A\s*<at((?:\s+#{ATTR_RE_SRC})+)\s*>(.*)</at>\s*\z}o
101
+ blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
102
+ when %r{\A\s*\{::at((?:\s+#{ATTR_RE_SRC})+)\}(.*)\{:/at\}\s*\z}o
103
+ blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
104
+
85
105
  # Fenced code block
86
106
  when /\A\s*```(\w*)\s*\z/
87
107
  lang = Regexp.last_match(1)
@@ -176,6 +196,23 @@ module Przn
176
196
  i -= 1
177
197
  blocks << {type: :ordered_list, items: items}
178
198
 
199
+ # Image, XML form: <img src="path" alt="..." title="..." {:attrs}/>
200
+ # Equivalent to the markdown `![alt](src "title"){:attrs}` form below
201
+ # — emits the same `:image` block so the renderer handles both
202
+ # identically. `src` is required; all other attributes pass through
203
+ # to `block[:attrs]` (string-keyed, matching markdown's IAL parse) so
204
+ # `relative_height`, `width`, etc. work the same way.
205
+ when %r{\A\s*<img((?:\s+#{ATTR_RE_SRC})+)\s*/>\s*\z}o
206
+ raw = parse_xml_attrs(Regexp.last_match(1))
207
+ path = raw.delete(:src)
208
+ if path
209
+ alt = raw.delete(:alt).to_s
210
+ title = raw.delete(:title)
211
+ attrs = raw.transform_keys(&:to_s)
212
+ normalize_image_attrs!(attrs)
213
+ blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
214
+ end
215
+
179
216
  # Image: ![alt](path "title"){:attrs}
180
217
  when /\A!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]*)")?\)(.*)/
181
218
  alt = Regexp.last_match(1)
@@ -194,6 +231,7 @@ module Przn
194
231
  attr_str = attr_str.sub(/\}\s*\z/, '')
195
232
  parse_image_attrs(attr_str, attrs)
196
233
  end
234
+ normalize_image_attrs!(attrs)
197
235
  blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
198
236
 
199
237
  # Definition list: term on one line, : definition on next
@@ -237,12 +275,14 @@ module Przn
237
275
  attrs.slice(:face, :size, :color)
238
276
  end
239
277
 
240
- # Generic attribute scanner — `key="value"` pairs, returned as a hash with
241
- # symbolized keys. Doesn't validate which keys are allowed; callers slice.
278
+ # Generic attribute scanner — three value forms accepted:
279
+ # key="value" key='value' key=bareword
280
+ # Returns a hash with symbolized keys. Doesn't validate which keys
281
+ # are allowed; callers slice.
242
282
  def parse_xml_attrs(str)
243
283
  attrs = {}
244
- str.scan(/(\w+)="([^"]+)"/) do |key, value|
245
- attrs[key.to_sym] = value
284
+ str.scan(/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s=<>"'`]+))/) do |key, dq, sq, uq|
285
+ attrs[key.to_sym] = dq || sq || uq
246
286
  end
247
287
  attrs
248
288
  end
@@ -254,6 +294,22 @@ module Przn
254
294
  end
255
295
  end
256
296
 
297
+ # Rewrite `height="N%"` / `width="N%"` into the canonical
298
+ # `relative_height="N"` / `relative_width="N"` the renderer reads.
299
+ # Values without a `%` suffix pass through unchanged (and are
300
+ # ignored downstream); an explicit `relative_*` already on the
301
+ # block wins so authors can mix forms without surprise.
302
+ def normalize_image_attrs!(attrs)
303
+ if (h = attrs['height']) && (m = h.match(/\A(\d+)%\z/))
304
+ attrs.delete('height')
305
+ attrs['relative_height'] ||= m[1]
306
+ end
307
+ if (w = attrs['width']) && (m = w.match(/\A(\d+)%\z/))
308
+ attrs.delete('width')
309
+ attrs['relative_width'] ||= m[1]
310
+ end
311
+ end
312
+
257
313
  def parse_table(lines)
258
314
  rows = []
259
315
  lines.each do |line|
@@ -275,9 +331,9 @@ module Przn
275
331
  segments << [:tag, scanner[2], scanner[1]]
276
332
  elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
277
333
  segments << [:tag, scanner[2], scanner[1]]
278
- elsif scanner.scan(/<font((?:\s+\w+="[^"]+")+)\s*>(.*?)<\/font>/)
334
+ elsif scanner.scan(%r{<font((?:\s+#{ATTR_RE_SRC})+)\s*>(.*?)</font>}o)
279
335
  segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
280
- elsif scanner.scan(/\{::font((?:\s+\w+="[^"]+")+)\}(.*?)\{:\/font\}/)
336
+ elsif scanner.scan(%r{\{::font((?:\s+#{ATTR_RE_SRC})+)\}(.*?)\{:/font\}}o)
281
337
  segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
282
338
  elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
283
339
  segments << [:note, scanner[1]]
@@ -304,7 +360,21 @@ module Przn
304
360
  end
305
361
  end
306
362
 
307
- segments
363
+ # Coalesce adjacent :text segments. The scanner has to bail to a
364
+ # single-character `.` when it sees `&` so the `&lt;` / `&gt;` /
365
+ # `&amp;` entity matches can run on the next iteration, which
366
+ # leaves a bare `&` as its own segment and fragments the
367
+ # surrounding text. Merging them back together means one OSC 66
368
+ # multicell sequence per typeset run — important for h1 titles
369
+ # under a proportional font, where Echoes pads each run
370
+ # independently and stray segments become visible gaps.
371
+ segments.each_with_object([]) do |seg, acc|
372
+ if seg[0] == :text && acc.last && acc.last[0] == :text
373
+ acc.last[1] = acc.last[1] + seg[1]
374
+ else
375
+ acc << seg
376
+ end
377
+ end
308
378
  end
309
379
  end
310
380
  end
data/lib/przn/renderer.rb CHANGED
@@ -109,6 +109,7 @@ module Przn
109
109
  when :image then render_image(block, width, row)
110
110
  when :blank then row + DEFAULT_SCALE
111
111
  when :bg then row
112
+ when :at then render_at(block); row
112
113
  else row + 1
113
114
  end
114
115
  end
@@ -137,6 +138,48 @@ module Przn
137
138
  @terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
138
139
  end
139
140
 
141
+ # Place text at an absolute (column, row) on the slide, escaping the
142
+ # normal top-down paragraph flow. Coordinates are 1-based terminal cells
143
+ # to match the CSI cursor-position escape. A trailing `%` interprets the
144
+ # value as a percentage of the terminal's width (for `x=`) or height
145
+ # (for `y=`) — `x="50%" y="50%"` lands at the middle of the pane,
146
+ # auto-resizing with the terminal. Content is parsed inline so
147
+ # `<size>`, `<color>`, `<font>`, **bold**, etc. all work inside `<at>`.
148
+ # The block contributes 0 to the slide's layout height so it doesn't
149
+ # push subsequent content down.
150
+ def render_at(block)
151
+ attrs = block[:attrs] || {}
152
+ x = resolve_at_coord(attrs[:x], @terminal.width)
153
+ y = resolve_at_coord(attrs[:y], @terminal.height)
154
+ return if x.nil? || y.nil?
155
+
156
+ segments = Parser.parse_inline(block[:content].to_s)
157
+ @terminal.move_to(y, x)
158
+ @terminal.write render_segments_scaled(segments, DEFAULT_SCALE)
159
+ end
160
+
161
+ # Resolve an `<at>` coordinate string against the dimension it indexes.
162
+ # `"50%"` → halfway along `max`; plain integer string → that number of
163
+ # cells. Out-of-range values clamp into [1, max]. Returns nil when the
164
+ # input is missing or unparseable so the renderer skips silently.
165
+ def resolve_at_coord(raw, max)
166
+ return nil if raw.nil?
167
+
168
+ s = raw.to_s.strip
169
+ return nil if s.empty?
170
+
171
+ cells =
172
+ if s.end_with?('%')
173
+ pct = s.chomp('%').to_f
174
+ (pct / 100.0 * max).round
175
+ elsif s =~ /\A-?\d+\z/
176
+ s.to_i
177
+ end
178
+ return nil if cells.nil?
179
+
180
+ cells.clamp(1, max)
181
+ end
182
+
140
183
  # Bottom-row progress indicator (Rabbit-style):
141
184
  #
142
185
  # 1 🐢 🐇 9
@@ -415,9 +458,19 @@ module Przn
415
458
  img_w, img_h = img_size
416
459
  cell_w, cell_h = @terminal.cell_pixel_size
417
460
 
418
- available_rows = @terminal.height - row - 2
419
- left = content_left(width)
420
- available_cols = width - left * 2
461
+ attrs = block[:attrs] || {}
462
+ abs_x = resolve_at_coord(attrs['x'], @terminal.width)
463
+ abs_y = resolve_at_coord(attrs['y'], @terminal.height)
464
+ absolute = abs_x && abs_y
465
+
466
+ origin_row = absolute ? abs_y : row
467
+ available_rows = [@terminal.height - origin_row - 2, 1].max
468
+ if absolute
469
+ available_cols = [@terminal.width - abs_x + 1, 1].max
470
+ else
471
+ left = content_left(width)
472
+ available_cols = width - left * 2
473
+ end
421
474
 
422
475
  # Cap the default vertical area to 70 % of the screen, matching what
423
476
  # `{:relative_height="70"}` would do explicitly. Large images that
@@ -426,7 +479,7 @@ module Przn
426
479
  # smaller images sit well within this cap so they're unaffected.
427
480
  # An explicit `relative_height` still overrides.
428
481
  default_rh = DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT
429
- rh = block[:attrs]['relative_height'] || default_rh
482
+ rh = attrs['relative_height'] || default_rh
430
483
  target_rows = (@terminal.height * rh.to_i / 100.0).to_i
431
484
  available_rows = [target_rows, available_rows].min
432
485
 
@@ -439,24 +492,29 @@ module Przn
439
492
  target_cols = [target_cols, 1].max
440
493
  target_rows = [target_rows, 1].max
441
494
 
442
- x = [(width - target_cols) / 2, 0].max
495
+ if absolute
496
+ y_cell, x_cell = abs_y, abs_x
497
+ else
498
+ y_cell = row
499
+ x_cell = [(width - target_cols) / 2, 0].max + 1
500
+ end
443
501
 
444
502
  if ImageUtil.kitty_terminal? && ImageUtil.png?(path)
445
503
  image_id = ensure_kitty_uploaded(path)
446
- @terminal.move_to(row, x + 1)
504
+ @terminal.move_to(y_cell, x_cell)
447
505
  @terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows)
448
506
  elsif ImageUtil.kitty_terminal?
449
- data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
507
+ data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x_cell - 1, y: y_cell - 1)
450
508
  @terminal.write data if data && !data.empty?
451
509
  elsif ImageUtil.sixel_available?
452
- @terminal.move_to(row, x + 1)
510
+ @terminal.move_to(y_cell, x_cell)
453
511
  target_pixel_w = target_cols * cell_w
454
512
  target_pixel_h = target_rows * cell_h
455
513
  sixel = cached_sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
456
514
  @terminal.write sixel if sixel && !sixel.empty?
457
515
  end
458
516
 
459
- row + target_rows
517
+ absolute ? row : row + target_rows
460
518
  end
461
519
 
462
520
  def resolve_image_path(path)
@@ -830,11 +888,20 @@ module Przn
830
888
  when :table
831
889
  ((block[:header] ? 2 : 0) + block[:rows].size) * s
832
890
  when :image
833
- image_block_height(block, width)
891
+ # Absolute-positioned images (`<img x y src/>`) layer on top of
892
+ # the slide and contribute 0 to the layout — same treatment as :at.
893
+ attrs = block[:attrs] || {}
894
+ if resolve_at_coord(attrs['x'], @terminal.width) && resolve_at_coord(attrs['y'], @terminal.height)
895
+ 0
896
+ else
897
+ image_block_height(block, width)
898
+ end
834
899
  when :align
835
900
  0
836
901
  when :bg
837
902
  0
903
+ when :at
904
+ 0
838
905
  when :blank
839
906
  s
840
907
  else
@@ -76,6 +76,7 @@ module Przn
76
76
  paths
77
77
  ensure
78
78
  @terminal.write "#{OSC};bg-clear#{BEL}"
79
+ @terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
79
80
  @terminal.show_cursor
80
81
  @terminal.leave_alt_screen
81
82
  @terminal.flush
data/lib/przn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Przn
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/przn.rb CHANGED
@@ -59,6 +59,7 @@ module Przn
59
59
  end
60
60
  ensure
61
61
  terminal.write "\e]7772;bg-clear\a"
62
+ terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
62
63
  terminal.show_cursor
63
64
  terminal.leave_alt_screen
64
65
  end
data/sample/sample.md CHANGED
@@ -48,6 +48,22 @@ normal and {::tag name="red"}red text{:/tag} mixed
48
48
 
49
49
  ![](doge.jpg){:relative_height="70"}
50
50
 
51
+ # Image (XML form)
52
+
53
+ <img src="doge.png" relative_height="70"/>
54
+
55
+ # Image (absolute position)
56
+
57
+ <img src="doge.png" x="5" y="3" relative_height="40"/>
58
+ <img src="doge.png" x="50%" y="50%" relative_height="40"/>
59
+
60
+ # Absolute-position text
61
+
62
+ <at x="10" y="10">top-left ish</at>
63
+ <at x="40" y="15"><size=3>BIG</size></at>
64
+ <at x="80" y="25"><color=red>warn</color></at>
65
+ <at x="50%" y="50%">dead center</at>
66
+
51
67
  # Thank You!
52
68
 
53
69
  That's all! Enjoy!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: przn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akira Matsuda